昨天我們把 Long-term Memory 和 User Profile 分開了。
現在 Memora 會直接從 user_profile.json 取得目前的英文程度、學習目標與回答偏好;同時保留 Day 18 的 Retrieval,在需要時從 Chroma 找回相關的過去紀錄。不過Chroma 裡的所有 Memory 目前仍然長得一樣:
Document
Embedding
Metadata
對程式來說,下面兩筆資料都只是普通的 Memory:
使用者的英文程度是 B1。
使用者昨天完成了機場報到英文練習。
但它們描述的並不是同一種事情。第一筆比較像對使用者的一般認知;第二筆則記錄某一次發生過的經歷。今天要替既有的 Memory Record 加入:
memory_type
讓 Memora 開始分辨 Semantic Memory 與 Episodic Memory。
Semantic Memory 可以先理解成:
不必依賴某一次特定事件,也能成立的事實、概念或一般認知。
放進 Memora 的情境,可能是:
使用者的英文程度是 B1。
使用者希望加強旅遊英文。
使用者偏好透過簡短例句學習。
使用者經常混淆 present perfect 和 past simple。
這些資料的重點不是「哪一天說過」,而是它們能不能在未來協助 Memora 理解使用者或調整教學方式。
Semantic Memory 不一定要經過很多次對話才能形成。如果使用者明確說出:
我的英文程度是 B1。
即使只說過一次,它仍然可以被抽取成 Semantic Memory。
Episodic Memory 則比較像一段具體經歷:
某一次互動、行動或事件曾經發生過。
例如:
使用者昨天完成了五題現在完成式練習。
使用者上週練習過機場報到對話。
使用者在上一堂練習中答錯了第三題。
使用者曾經請 Memora 比較 travel 和 journey。
這類 Memory 通常帶有某次互動的背景,可能和時間、任務或結果有關。它的價值不一定是永久改變 Memora 對使用者的認知,而是讓後續對話可以接回過去發生過的事情。例如使用者問:
上次我做錯的是哪一題?
這時真正需要的通常不是 Profile,而是某次練習留下的 Episodic Memory。
可以先用這張表整理:
| 比較項目 | Semantic Memory | Episodic Memory |
|---|---|---|
| 核心問題 | 我們知道什麼? | 曾經發生什麼? |
| 內容特性 | 事實、偏好、一般認知 | 特定互動、事件、任務結果 |
| 是否依賴事件背景 | 通常較低 | 通常較高 |
| 例子 | 使用者的程度是 B1 | 使用者昨天完成 B1 閱讀練習 |
| 常見用途 | 個人化、教學調整 | 回顧進度、延續過去任務 |
這個分類不是單靠句子裡有沒有「昨天」就能完成。
例如:
使用者昨天說自己偏好簡短例句。
如果想保留的是「昨天說過這件事」,它比較接近 Episodic Memory;如果想讓未來回答都採用簡短例句,真正有用的內容則是:
使用者偏好簡短例句。
也就是 Semantic Memory。
分類的重點不是句子的表面格式,而是 Application 想保留哪一層意義。
看到這裡,可能會覺得 Semantic Memory 和昨天的 User Profile 幾乎一樣。
它們確實可能包含相同內容:
Semantic Memory
使用者的英文程度是 B1。
User Profile
english_level = B1
但在目前的架構中,兩者的用途仍然不同:
| Semantic Memory | User Profile | |
|---|---|---|
| 資料型態 | 多筆可搜尋紀錄 | 一份固定欄位的目前狀態 |
| 取得方式 | Semantic Search | 直接讀取欄位 |
| 是否一定代表現在 | 不一定 | 代表 Application 目前採用的值 |
| 例子 | 使用者曾被記錄為 B1 | english_level 目前是 B2 |
假設使用者從 B1 進步到 B2:
Past Semantic Memory
使用者的英文程度是 B1。
Current User Profile
english_level = B2
B1 這筆 Memory 可以仍然是過去曾經成立的紀錄,但本次教學應採用 Profile 裡的 B2。
因此 Day 19 已經在 build_background_messages() 定義:Profile 和 Past Memory 發生衝突時,優先使用 Profile;如果目前 User Message 又提供更新資訊,則以目前訊息為準。
今天不改變這個優先順序,只替 Long-term Memory 本身增加類型。
先在 Import 區加入:
from typing import Literal
接著定義兩個 Type Alias:
ExtractedMemoryType = Literal[
"semantic",
"episodic"
]
StoredMemoryType = Literal[
"semantic",
"episodic",
"unclassified"
]
為什麼要分成兩個?
新抽取的 Memory 必須由 LLM 在 semantic 與 episodic 之間選擇,因此 ExtractedMemoryType 只有兩種合法值。
但是 Day 17 到 Day 19 已經存進 Chroma 的舊資料沒有 memory_type。如果直接把它們全部假設成 Semantic Memory,可能會把過去的事件標錯。因此從 Database 讀取時,額外保留:
unclassified
它不是第三種 Memory,而是代表:
這筆舊資料尚未分類。
這樣不需要刪除原本的 memora_db,也不必為了新增欄位假裝知道舊 Memory 的類型。
Day 13 建立的 MemoryCandidate 原本只負責保存抽取後的文字。現在替它加入 memory_type:
class MemoryCandidate(BaseModel):
content: str
memory_type: ExtractedMemoryType
接著修改 Day 14 的 EmbeddedMemoryCandidate:
class EmbeddedMemoryCandidate(BaseModel):
content: str
memory_type: ExtractedMemoryType
embedding: list[float]
MemoryCandidate 是 LLM Structured Output 的結果;EmbeddedMemoryCandidate 則是在同一筆資料加上 Embedding 後,準備送進 LongTermMemoryStore.add() 的物件。
最後修改 Day 17 的兩個回傳 Model:
class StoredMemory(BaseModel):
memory_id: str
content: str
memory_type: StoredMemoryType
source: str
created_at: str
class MemorySearchResult(BaseModel):
memory_id: str
content: str
memory_type: StoredMemoryType
source: str
created_at: str
distance: float
score: float
目前資料流就會變成:
MemoryCandidate
content + memory_type
EmbeddedMemoryCandidate
content + memory_type + embedding
StoredMemory / MemorySearchResult
從 Chroma 讀回 content + memory_type + metadata
這次沒有建立另一套 Memory Object,只是在原本的資料模型上增加同一個欄位。
只修改 Pydantic Model 還不夠。Memory Extraction Prompt 也要告訴模型兩種類型的判斷方式。
在 Day 13 延續使用的 Memory Extraction Prompt 中,保留原本「只抽取值得跨 Conversation 使用的資訊」等規則,再加入:
For each extracted memory, choose one memory_type:
- semantic:
A fact, preference, goal, ability, or general observation
that can be useful without referring to one specific event.
- episodic:
A specific past interaction, activity, event, or result.
Use only "semantic" or "episodic".
Do not classify a memory only by keywords such as
"yesterday" or "last week". Classify the meaning that the
memory preserves.
Examples:
- "The user's English level is B1."
→ semantic
- "The user prefers short examples."
→ semantic
- "The user completed an airport check-in exercise yesterday."
→ episodic
- "The user answered three present-perfect questions incorrectly
in the previous practice session."
→ episodic
因為 MemoryCandidate 已經成為 Structured Output Schema 的一部分,模型不只要產生:
{
"content": "The user's English level is B1."
}
而是必須產生:
{
"content": "The user's English level is B1.",
"memory_type": "semantic"
}
原本負責解析 Structured Output 的 extract_memories() 不需要換成另一種呼叫方式。只要它仍然使用包含 MemoryCandidate 的既有 Pydantic Schema,解析結果就會多出 memory_type。
Embedding 仍然只根據 content 建立。memory_type 是 Metadata,不需要轉換成向量。
因此 Day 14 原本送給 create_embeddings() 的內容不變:
memory_contents = [
memory_item.content
for memory_item in memory_candidates
]
但在組合 EmbeddedMemoryCandidate 時,要把分類結果一起帶過去。
原本如果是:
EmbeddedMemoryCandidate(
content=memory_item.content,
embedding=embedding
)
現在改成:
EmbeddedMemoryCandidate(
content=memory_item.content,
memory_type=memory_item.memory_type,
embedding=embedding
)
這個欄位必須一路保留:
LLM Classification
→ MemoryCandidate.memory_type
→ EmbeddedMemoryCandidate.memory_type
→ Chroma Metadata
如果中間少了這一步,Structured Output 雖然分類成功,寫入 Database 時仍然只剩文字和向量。
LongTermMemoryStore.add()接著從 Day 17 的 LongTermMemoryStore.add() 繼續修改。
原本每筆 Metadata 是:
{
"source": source,
"created_at": created_at
}
現在加入各自的 memory_type:
def add(
self,
memories: list[EmbeddedMemoryCandidate],
source: str
):
if not memories:
return []
memory_ids = [
str(uuid4())
for _ in memories
]
created_at = datetime.now(
timezone.utc
).isoformat()
self.collection.add(
ids=memory_ids,
documents=[
memory.content
for memory in memories
],
embeddings=[
memory.embedding
for memory in memories
],
metadatas=[
{
"source": source,
"created_at": created_at,
"memory_type": memory.memory_type
}
for memory in memories
]
)
return memory_ids
這裡只修改 metadatas,其餘的 ID、Document、Embedding 與 created_at 都沿用 Day 17。
現在一筆 Chroma Record 可以理解成:
Document
The user completed an airport exercise yesterday.
Embedding
[0.021, -0.037, ...]
Metadata
source = automatic
created_at = 2026-09-09T03:20:00+00:00
memory_type = episodic
memory_type 不會改變 Embedding,也不會自動提高某一類 Memory 的相似度。它的作用是讓 Application 讀回資料後知道這筆 Memory 屬於哪一類,未來也可以作為 Metadata Filter。
新的 Record 都會有 memory_type,但舊的 Chroma Records 沒有。
先新增一個 Helper:
def read_memory_type(
metadata: dict
) -> StoredMemoryType:
memory_type = metadata.get("memory_type")
if memory_type in {
"semantic",
"episodic"
}:
return memory_type
return "unclassified"
接著修改 LongTermMemoryStore.list_all() 建立 StoredMemory 的位置:
StoredMemory(
memory_id=memory_id,
content=document,
memory_type=read_memory_type(metadata),
source=metadata.get(
"source",
"unknown"
),
created_at=metadata.get(
"created_at",
"unknown"
)
)
同樣地,在 LongTermMemoryStore.search() 建立 MemorySearchResult 的位置加入:
MemorySearchResult(
memory_id=memory_id,
content=document,
memory_type=read_memory_type(metadata),
source=metadata.get(
"source",
"unknown"
),
created_at=metadata.get(
"created_at",
"unknown"
),
distance=float(distance),
score=1 - float(distance)
)
原本兩個方法裡已經有:
metadata = metadata or {}
這行繼續保留,再把整理後的 Dictionary 交給 read_memory_type()。
如此一來:
Day 20 之後新增的資料
→ semantic 或 episodic
Day 19 以前已存在的資料
→ unclassified
舊資料仍然可以列出與搜尋,不需要刪掉整個 Database。
remember CommandDay 17 加入的手動指令原本是:
remember <memory>
現在 MemoryCandidate 多了必要欄位,手動建立 Memory 時也必須指定類型。
把格式改成:
remember semantic <memory>
remember episodic <memory>
例如:
remember semantic 使用者的英文程度是 B1。
remember episodic 使用者今天完成了機場報到練習。
在既有 remember 分支中,把 command_value 再切成兩部分:
remember_parts = command_value.strip().split(
maxsplit=1
)
if (
len(remember_parts) != 2
or remember_parts[0].lower()
not in {"semantic", "episodic"}
):
print(
"Usage: remember "
"<semantic|episodic> <memory>"
)
continue
memory_type = remember_parts[0].lower()
memory_content = remember_parts[1].strip()
memory_candidate = MemoryCandidate(
content=memory_content,
memory_type=memory_type
)
接下來直接把 memory_candidate 放回原本 Day 14 建立 Embedding、Day 17 呼叫 long_term_memory.add() 的流程,不需要新增另一條手動儲存管線。
這次不替舊格式自動指定 semantic,因為:
remember 使用者昨天完成了練習。
明顯比較像 Episodic Memory。讓使用者明確指定,可以避免 Application 靜默做出錯誤分類。
現在輸入:
memories
時,可以在原本的輸出多印一行:
print(
f" Type: "
f"{stored_memory.memory_type}"
)
結果可能是:
1. 使用者的英文程度是 B1。
Type: semantic
Source: automatic
Created at: 2026-09-09T03:20:00+00:00
2. 使用者昨天完成了機場報到練習。
Type: episodic
Source: automatic
Created at: 2026-09-09T03:25:00+00:00
3. 使用者偏好簡短例句。
Type: unclassified
Source: automatic
Created at: 2026-09-08T06:10:00+00:00
第三筆不是第三種 Memory,只是 Day 20 以前建立、尚未分類的 Record。
search <query> 的輸出也可以加入:
print(
f" Type: "
f"{search_result.memory_type}"
)
這樣可以直接觀察 Retrieval 找回了哪一種類型。
Day 19 已經新增:
build_memory_context()
它會把 Retrieved Memory 整理後交給 build_background_messages()。現在只要替每一筆內容加上類型標記:
def build_memory_context(
memories: list[MemorySearchResult]
) -> str:
return "\n".join(
(
f"- [{memory_item.memory_type}] "
f"{memory_item.content}"
)
for memory_item in memories
)
模型看到的 Long-term Memory Section 就可能是:
<long_term_memories>
- [semantic] 使用者的英文程度是 B1。
- [episodic] 使用者昨天完成了機場報到練習。
</long_term_memories>
Day 19 的 build_background_messages()、Profile 優先順序與 prepare_context(background_messages=...) 都不需要修改。
一般聊天仍然走原本的路徑:
Current User Input
→ retrieve_relevant_memories()
→ LongTermMemoryStore.search()
→ build_background_messages()
→ memory.prepare_context()
→ Responses API
今天只是讓 Search Result 多帶一個 Type,並沒有重寫 Retrieval。
雖然 memory_type 已經存進 Chroma Metadata,但目前:
LongTermMemoryStore.search()
仍然會在所有 Memory 中執行 Vector Search。
也就是說,Query:
我上次做過什麼練習?
可能同時取得 Semantic 與 Episodic Candidates,再依照 Day 18 的相似度門檻篩選。
今天先不加入:
where={"memory_type": "episodic"}
因為 Application 必須先判斷目前問題需要哪種類型,才知道應該套用什麼 Filter。這已經不只是資料分類,而是 Retrieval Policy。
目前先完成三件事:
抽取時分類
儲存時保留類型
讀取時帶回類型
之後要依照 Type 搜尋時,就不必重新改造資料結構。
created_at 不是事件發生時間Episodic Memory 通常和時間有關,而 Day 17 已經保存:
created_at
但這裡要注意:
created_at表示 Memory Record 寫進 Store 的時間,不一定是事件真正發生的時間。
假設使用者今天說:
我上週完成了機場英文練習。
created_at 是今天;事件時間則是上週。
目前事件的時間資訊仍然保留在 content 中,Day 20 不額外加入 event_at,避免在分類之外又引入日期解析與不確定時間推理。
Day 23 使用 created_at 討論 Recency 時,計算的會是「這筆 Memory 被建立多久」,不能直接把它解讀成事件距今多久。
現在可以輸入:
You: 我的英文程度是 B1,我比較喜歡簡短的例句。
Memory Extraction 可能產生:
{
"memories": [
{
"content": "The user's English level is B1.",
"memory_type": "semantic"
},
{
"content": "The user prefers short examples.",
"memory_type": "semantic"
}
]
}
接著輸入:
You: 我今天完成了五題機場報到英文練習。
可能得到:
{
"memories": [
{
"content": "The user completed five airport check-in exercises today.",
"memory_type": "episodic"
}
]
}
最後輸入:
You: memories
確認每筆新資料都具有 Type,再使用 search <query> 檢查搜尋結果是否仍然正常。
LLM Classification 並不保證每次都符合我們心中的邊界。同一句話可能同時包含穩定事實和特定事件,理想情況下應被拆成兩筆 Atomic Memory;如果分類結果仍然不穩定,就需要更多例子、測試資料與評估規則。今天先讓資料具有可觀察的 Type,下一步才有辦法討論「哪些類型、哪些內容真的應該留下」。
今天直接從 Day 19 的架構繼續,保留原本的:
UserProfileStore
Memory Extraction
Embedding
LongTermMemoryStore
Memory Retrieval
build_background_messages()
Short-term Context Management
我們新增了:
ExtractedMemoryType
StoredMemoryType
MemoryCandidate.memory_type
EmbeddedMemoryCandidate.memory_type
StoredMemory.memory_type
MemorySearchResult.memory_type
並把 memory_type 一路傳進 Chroma Metadata,再從 list_all() 與 search() 讀回來。舊資料沒有這個欄位時,則標記為 unclassified,不必刪除原本的 Store。
今天最重要的觀念是:
Semantic Memory 保存不依賴特定事件的一般認知;Episodic Memory 保存某次發生過的互動或經歷。分類不是為了替資料貼標籤,而是替後續的寫入、搜尋與遺忘策略提供可以使用的結構。
不過,現在只要 Memory Extraction 覺得某句話值得保存,它仍然會被寫進 Long-term Memory。
但「可以分類」不代表「應該保存」。
下一篇我們會正式建立 Memory Policy,讓 Application 開始判斷:
哪些內容允許保存?
哪些只應留在目前 Conversation?
使用者明確要求記住時怎麼處理?
哪些敏感或短暫資訊不應進入 Long-term Memory?
Memora 已經知道 Memory 屬於哪一類。接下來,要決定它到底有沒有資格被留下。